spb/market-atlas
Public
TypeScript 96.7%
SQL 1.6%
CSS 0.8%
JavaScript 0.5%
1import type { Metadata } from "next";2import Link from "next/link";3import { EventRow } from "@/components/market/event-row";4import { InstrumentTable } from "@/components/market/instrument-table";5import { Empty, Kv, Page, PageHeader, Section, Stat } from "@/components/ui/section";6import { StatusBadge } from "@/components/ui/status-badge";7import { api } from "@/lib/api";8import { formatDateTime } from "@/lib/format";9import type { Breadth, Exchange, ExchangeStatus, InstrumentWithQuote, MarketEvent } from "@/lib/types";1011interface Detail {12 exchange: Exchange;13 status: ExchangeStatus;14 holidays: Array<{ date: string; name: string; kind: string; closeTime: string | null }>;15 instruments_tracked: number;16 instruments_quoted: number;17 breadth: Breadth;18 gainers: InstrumentWithQuote[];19 losers: InstrumentWithQuote[];20 most_active: InstrumentWithQuote[];21 events: MarketEvent[];22}2324export const dynamic = "force-dynamic";2526export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {27 const { id } = await params;28 try {29 const d = await api<Detail>(`/v1/exchanges/${encodeURIComponent(id)}`);30 return { title: `${d.exchange.name} — status, hours, movers`, description: `${d.exchange.name} (${d.exchange.mic ?? d.exchange.id}): session state, local time, holidays, breadth and the largest moves among instruments Market Atlas tracks.`, alternates: { canonical: `/exchanges/${d.exchange.id}` } };31 } catch {32 return { title: "Exchange" };33 }34}3536export default async function ExchangePage({ params }: { params: Promise<{ id: string }> }) {37 const { id } = await params;38 const d = await api<Detail>(`/v1/exchanges/${encodeURIComponent(id)}`);39 const e = d.exchange;40 const sessions = e.sessions;41 const sessionText = sessions.continuous ? "Continuous (24/7)" : sessions.regular.map((s) => `${s.open}–${s.close}`).join(", ");42 return (43 <Page wide>44 <PageHeader45 kicker={46 <span className="flex items-center gap-2">47 <Link href="/exchanges" className="hover:text-ink">48 Exchanges49 </Link>50 <span>·</span>51 <Link href={`/countries/${e.country}`} className="hover:text-ink">52 {e.country}53 </Link>54 </span>55 }56 title={e.name}57 lead={`${e.operator ? `${e.operator} · ` : ""}${e.city ?? ""} · ${e.timezone}${e.website ? "" : ""}`}58 actions={59 <div className="flex items-center gap-2 text-sm">60 <StatusBadge status={d.status.state} />61 <span className="mono text-ink-2">local {d.status.localTime}</span>62 {e.website && (63 <a href={e.website} target="_blank" rel="noopener noreferrer" className="text-xs text-accent hover:underline">64 website ↗65 </a>66 )}67 </div>68 }69 />70 <div className="grid grid-cols-2 gap-4 rounded-md border border-rule bg-surface px-4 py-4 sm:grid-cols-4">71 <Stat label="Session" value={d.status.state.toLowerCase()} sub={d.status.isHoliday ? d.status.holidayName ?? "holiday" : sessionText} />72 <Stat label="Next transition" value={d.status.nextTransition ? d.status.nextTransition.state.toLowerCase() : "—"} sub={d.status.nextTransition ? formatDateTime(d.status.nextTransition.at, { tz: e.timezone }) : sessions.continuous ? "never closes" : ""} />73 <Stat label="Instruments tracked" value={d.instruments_tracked.toLocaleString("en-US")} sub={`${d.instruments_quoted} with a canonical quote`} />74 <Stat label="Breadth" value={d.breadth.instruments ? `${d.breadth.advancers} ▲ ${d.breadth.decliners} ▼` : "—"} sub={d.breadth.instruments ? `median ${d.breadth.median_change_percent?.toFixed(2)}%` : "no quoted names"} tone={d.breadth.advancers > d.breadth.decliners ? "pos" : d.breadth.decliners > d.breadth.advancers ? "neg" : undefined} />75 </div>76 <div className="mt-6 grid grid-cols-1 [&>*]:min-w-0 gap-6 lg:grid-cols-[1fr_320px]">77 <div>78 <Section title="Largest gains" hint="instruments tracked on this venue">79 <InstrumentTable rows={d.gainers} compact showExchange={false} emptyText="No quoted instruments on this venue yet." />80 </Section>81 <Section title="Largest losses">82 <InstrumentTable rows={d.losers} compact showExchange={false} defaultSort="change" emptyText="No quoted instruments on this venue yet." />83 </Section>84 <Section title="Most active">85 <InstrumentTable rows={d.most_active} compact showExchange={false} defaultSort="volume" emptyText="No volume observed yet." />86 </Section>87 <Section title="Events" hint="market open/close, halts, moves">88 {d.events.length ? (89 <ul className="rounded-md border border-rule bg-surface px-3">90 {d.events.map((ev) => (91 <EventRow key={ev.id} e={ev} />92 ))}93 </ul>94 ) : (95 <Empty>No events recorded for this venue yet.</Empty>96 )}97 </Section>98 </div>99 <aside className="space-y-6">100 <div className="rounded-md border border-rule bg-surface p-4">101 <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Venue</h3>102 <Kv cols={1} className="mt-2" items={[["MIC", e.mic ?? "—"], ["Operator", e.operator ?? "—"], ["Country", e.country], ["Time zone", e.timezone], ["Currency", e.currency ?? "—"], ["Asset classes", (e.asset_classes ?? []).join(", ").toLowerCase() || "—"]]} />103 </div>104 <div className="rounded-md border border-rule bg-surface p-4">105 <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Trading sessions (local)</h3>106 <Kv107 cols={1}108 className="mt-2"109 items={[110 ...(sessions.pre ? ([["Pre-market", `${sessions.pre.open}–${sessions.pre.close}`]] as Array<[string, string]>) : []),111 ...sessions.regular.map((s, i) => [`Regular${sessions.regular.length > 1 ? ` ${i + 1}` : ""}`, `${s.open}–${s.close}`] as [string, string]),112 ...(sessions.post ? ([["After hours", `${sessions.post.open}–${sessions.post.close}`]] as Array<[string, string]>) : []),113 ...(sessions.continuous ? ([["Schedule", "24 hours, every day"]] as Array<[string, string]>) : []),114 ...(sessions.weekdays ? ([["Trading days", sessions.weekdays.map((d) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][d]).join(" ")]] as Array<[string, string]>) : []),115 ]}116 />117 </div>118 <div className="rounded-md border border-rule bg-surface p-4">119 <h3 className="text-[11px] font-medium uppercase tracking-wide text-ink-3">Upcoming holidays & early closes</h3>120 {d.holidays.length ? (121 <ul className="mt-2 divide-y divide-rule text-sm">122 {d.holidays.map((h) => (123 <li key={h.date} className="flex items-baseline justify-between gap-2 py-1.5">124 <span className="min-w-0 truncate">{h.name}</span>125 <span className="mono shrink-0 text-xs text-ink-3">126 {h.date}127 {h.kind === "EARLY_CLOSE" && h.closeTime ? ` · closes ${h.closeTime}` : ""}128 </span>129 </li>130 ))}131 </ul>132 ) : (133 <p className="mt-2 text-xs text-ink-3">No holiday calendar loaded for this venue — weekdays are assumed to trade.</p>134 )}135 </div>136 </aside>137 </div>138 </Page>139 );140}141